Skip to content

feat(ci): the bridge's two sides must agree on every RPC name - #1012

Merged
cryptskii merged 1 commit into
mainfrom
feat/bridge-rpc-name-gate
Sep 26, 2026
Merged

cryptskii merged 1 commit into
mainfrom
feat/bridge-rpc-name-gate

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

What

ci/bridge_rpc_names.py: every bridge RPC method name the frontend sends (production sources and the bridge object public/index.html installs — string literals to callBin/sendBridgeRequestBytes/buildBridgeRequest/callBoundaryMethod/callBridgeMethod/encodeBridgeRequest, plus an upper-case constant resolved in its file; unresolved fails) must equal the string arms of Kotlin's SinglePathWebViewBridge.handleBinaryRpcInternal, both ways, with no allowlist. It runs in the purity step, the Frontend job and the Android Unit Tests job, so a change on either side selects it.

Why

Nothing checked that the two sides agreed. The frontend sent hasIdentityDirect for months — answered by the unknown-method arm and wrapped in a default that downstream code read as a measurement — and the same phantom-method pattern surfaced four times in the frontend sweep (#1009). Kotlin kept eight arms nothing sent.

Also in this change

The eight dead arms are deleted — processEnvelopeV3, getWalletHistoryStrict, getSigningPublicKeyBin, getPersistedGenesisEnvelope, getGenesisHashBin, getDeviceIdBin, startNativeQrScanner, hasNativeQrScanner — with the getWalletHistoryStrict and getSigningPublicKeyBin Kotlin functions, externals and Rust JNI exports that only they reached, and the prefs reader only one of them used. The fuzz test's method list names live methods. Recorded in CONFORMANCE_GAPS.md §6.29; the __callBin jest seam that every transport path branches on is recorded there as Open (its own chunk).

Verification

  • Gate: 22 names sent, 22 handled, the same set. Negative controls, each failing and naming the offender: a phantom frontend name (exit 1), a dead Kotlin arm (exit 1), an unresolvable constant (exit 2); files restored byte-for-byte.
  • make lint (fmt + clippy) exit 0; cargo ndk -t arm64-v8a --platform 23 check --package dsm_sdk --features=jni,bluetooth exit 0 (two JNI exports removed).
  • Gradle: compileDebugKotlin, compileDebugAndroidTestKotlin (offline) and compileDebugUnitTestKotlin exit 0.
  • ci/no_clock_and_no_json.sh, flow assertions, flow mappings, forbidden symbols, ci_scan.sh, bridge contracts gate, production safety checks: all exit 0. The workflow file parses.

Nothing checked that the frontend and Kotlin agreed on the method names a
BridgeRpcRequest carries. The frontend sent names Kotlin never handled —
`hasIdentityDirect`, for months, answered by the unknown-method arm and
wrapped in a default that read as a measurement — and Kotlin kept eight arms
nothing sent: processEnvelopeV3, getWalletHistoryStrict,
getSigningPublicKeyBin, getPersistedGenesisEnvelope, getGenesisHashBin,
getDeviceIdBin, startNativeQrScanner, hasNativeQrScanner. The same pattern
surfaced four times in the frontend sweep (#1009).

`ci/bridge_rpc_names.py` reads the names the frontend's production sources
and `public/index.html` send (string literals to callBin,
sendBridgeRequestBytes, buildBridgeRequest, callBoundaryMethod,
callBridgeMethod, encodeBridgeRequest, and an upper-case constant resolved in
its file — unresolved fails) and the string arms of Kotlin's
`handleBinaryRpcInternal`, and requires the two sets to be equal, both ways,
with no allowlist. It runs in the purity step, the Frontend job and the
Android Unit Tests job, so a change on either side selects it.

The eight dead arms are deleted, with the getWalletHistoryStrict and
getSigningPublicKeyBin Kotlin functions, externals and Rust JNI exports that
only they reached, and the prefs reader only one of them used; the fuzz
test's method list names live methods.

Negative controls, each failing the gate naming the offender: a phantom
frontend name (exit 1), a dead Kotlin arm (exit 1), an unresolvable constant
(exit 2). Verified: the gate at 22 sent = 22 handled, make lint, cargo ndk
check of the jni feature for arm64-v8a, gradle compile of main, androidTest
and unit-test sources, and the purity, flow, scan, bridge and safety gates.
Comment thread ci/bridge_rpc_names.py
sent = {}
unresolved = []
for path in frontend_sources():
text = open(path, encoding="utf-8").read()
Comment thread ci/bridge_rpc_names.py
sent.setdefault(const.group(1), []).append(where)
else:
unresolved.append(f"{where} ({m.group(1)})")
html = open(INDEX_HTML, encoding="utf-8").read()
Comment thread ci/bridge_rpc_names.py


def handled_names():
text = open(KOTLIN_BRIDGE, encoding="utf-8").read()
@cryptskii
cryptskii merged commit eabdb36 into main Sep 26, 2026
19 of 21 checks passed
@cryptskii
cryptskii deleted the feat/bridge-rpc-name-gate branch September 26, 2026 05:50
cryptskii added a commit that referenced this pull request Sep 26, 2026
…op nothing sends, and the stubs of methods that no longer exist go

`isNativeHostUnavailableError` matched a Kotlin error string nothing called
it for. `processEnvelopeV3Bin` sent an `envelope` ingress op no production
code builds — the frontend has no envelope to hand Rust; BLE envelopes reach
Rust from Kotlin — and lived on through two tests that used it as their way
into the boundary. Three test bridges carried `getDeviceIdBin`,
`getGenesisHashBin` and `getTransportHeadersV3Bin` members index.html does
not install (one with a comment saying call sites read them; none do), and
two answered `getSigningPublicKeyBin`, an RPC deleted with #1012 — stubs of
methods that do not exist.

All deleted, with `buildEnvelopeIngressRequest`. The boundary tests enter
through `routerQueryBin`, the op production sends. `ci/bridge_rpc_names.py`
now also holds every `method === '…'` arm in a test bridge to a name Kotlin
handles, so a stub cannot outlive its method again.

Tests: WebViewBridge.framing (a router query goes to nativeBoundaryIngress
as a routerQuery op; a non-bytes native answer is refused),
bridgeDecoding.integration (the boundary's answers, entered through
routerQueryBin). Gate control, failing naming the offender: a test stub arm
answering getSigningPublicKeyBin. Verified: tsc, lint, the boundary and E2E
suites this touches, and the bridge-RPC-name gate; the full suite, build
and remaining gates ran on the tip, with the identity change that follows.
cryptskii added a commit that referenced this pull request Sep 26, 2026
…e wire, dead transport, identity readiness, dead exports (#1020)

* fix(wallet): one reload per announced change — the listener drops nothing, and the raw-event reloads beside it go

The one reload path, `wallet.refresh` → `useWalletRefreshListener`, had a
"cooldown" that counted dropped events rather than frames: after any reload
the next 119 events from a low-priority source (`inbox.sync`,
`storage.sync`, `sofi`, an adoption, a dialog's completion) were discarded
outright, and only nine named sources bypassed it. Around that gate the
provider and the wallet screen subscribed to the raw `inbox.updated` and
`bilateral.transferComplete` events as well, and the provider reloaded again
on `wallet.bilateralCommitted`, so one inbox sync with new items reloaded
the projection and the screen three to four times when the gate was open,
and the raw subscriptions were the only reload when it was shut. The event
bridge also emitted a `wallet.refresh` claiming `bilateral.transfer_complete`
on one BLE prepare response in eight — a prepare response is not a wallet
change.

The listener now coalesces onto an animation frame, runs one reload at a
time and owes exactly one more for events that arrive mid-reload: nothing is
dropped, and there are no priority classes. The provider and the screen hook
reload on `wallet.refresh` alone; the raw subscriptions and the provider's
reload on `wallet.bilateralCommitted` are deleted (its toast stays). The BLE
prepare-response emit is deleted. The accept path's staggered re-reads stay
beside Rust's TRANSFER_COMPLETE announcement and name themselves
`bilateral.accept_followup`; whether the announcement alone suffices on a
device is undecided and recorded Open (§6.29). The provider's store and the
wallet screen's hook still each hold a copy of balances and history — a
store duplication, recorded, not a reload duplication.

Tests: useWalletRefreshListener (nothing dropped after a completed refresh;
events mid-refresh owe exactly one more; no owed refresh after unmount),
WalletCreditSound (the event bridge's own announcements, decoded from bytes,
reload the projection once each; the coin sound follows the credit),
EnhancedWalletScreen.events (one inbox sync reloads the wallet data once),
EventBridge.bilateral (a BLE prepare response announces no wallet change);
the five suites that pinned the old behaviour now assert the production pair
of events and the accept follow-up's own name. Mutation controls, each red
on its named test: the drop gate reintroduced; the provider's raw
inbox.updated reload re-added; the screen hook's raw inbox.updated reload
re-added; the BLE prepare-response emit restored. Verified: tsc, lint, jest
843/843, npm run build, and the purity, flow, scan, bridge, codegen, proto
and bridge-RPC-name gates.

* fix(tokens): a balance row carries what its token is and what its policy fixes and permits; the screen's CPTA copy goes

AccountsScreen carried its own table of what ERA and dBTC are — a "DJTE
emission token" type, an invented anchor formula, a "PROTOCOL-DEFINED"
anchor id and a supply figure — beside the anchor Rust reports, and decided
whether a token is protocol-defined, which withholds BURN and the adoption
code, from its ticker text: a created token whose ticker read ERA would have
been treated as the protocol's. BURN was offered on every created token
whether or not its policy permits burning, and the coin artwork keyed on the
ticker containing "btc". A created token's row carried its policy's icon and
nothing else the policy fixes.

Rust now reports the facts on each `BalanceGetResponse`: `protocol_defined`,
decided from the builtin policy commit the ticker resolves to;
`genesis_supply_display`, the supply the committed policy fixes at creation,
rendered with the token's decimals — ERA's is the native reserve's genesis
supply, and it is empty where Rust holds none; and `permissions`
(`burn_enabled`, `transferable`), read from bytes verified against the anchor
and absent, never defaulted, where Rust holds no committed policy — ERA's
blob does not exist yet (§6.32), and "not stated" is not "not permitted". A
registered token's row is checked against its own policy (ticker, alias,
decimals, supply) and a disagreeing row is refused with nothing reported
from either side. The frontend refuses a created token's row that lacks its
policy facts, takes a token for a protocol asset on Rust's word only, draws
in the panel exactly the lines Rust reports, offers BURN only where the
policy permits it, and keys the protocol artwork on Rust's word plus the
ticker. `CPTA_INFO` is deleted. The Rust → TypeScript balance wire fixture
carries the new fields and its contract test decodes them, the permissions
present. The Bitcoin chain-balance row lists the new fields as unstated;
nothing else there changes.

Tests: dsm_sdk wallet_routes tests (ERA protocol-defined with the reserve's
supply and no stated permissions; a registered token's supply, permissions
and icon from a policy packed by the one packer and stored under its anchor;
a row disagreeing with its policy refused), AccountsScreen.tokens tests (a
created token's supply and permissions as Rust reports them; a protocol
asset's supply and nothing Rust does not state; no BURN where the policy
forbids it; protocol-ness on Rust's word, never the ticker), wallet.test (a
created token's row without its policy facts refused), balanceWireContract
(the fixture's new fields decode, permissions present). Mutation controls,
each red on its named test: ERA's permissions defaulted; a registered token
reported as protocol-defined; a disagreeing row reported anyway;
protocol-ness keyed on the ticker; BURN offered regardless of the policy;
permissions defaulted where none are stated; a factless created row
accepted. Verified: make lint, the production-safety lints, the dsm_sdk
release wallet_routes tests, tsc, lint, jest 840/840, npm run build, and the
purity, flow, scan, bridge, codegen, proto and bridge-RPC-name gates.

* chore(bridge): the dead host-unavailable probe, the envelope ingress op nothing sends, and the stubs of methods that no longer exist go

`isNativeHostUnavailableError` matched a Kotlin error string nothing called
it for. `processEnvelopeV3Bin` sent an `envelope` ingress op no production
code builds — the frontend has no envelope to hand Rust; BLE envelopes reach
Rust from Kotlin — and lived on through two tests that used it as their way
into the boundary. Three test bridges carried `getDeviceIdBin`,
`getGenesisHashBin` and `getTransportHeadersV3Bin` members index.html does
not install (one with a comment saying call sites read them; none do), and
two answered `getSigningPublicKeyBin`, an RPC deleted with #1012 — stubs of
methods that do not exist.

All deleted, with `buildEnvelopeIngressRequest`. The boundary tests enter
through `routerQueryBin`, the op production sends. `ci/bridge_rpc_names.py`
now also holds every `method === '…'` arm in a test bridge to a name Kotlin
handles, so a stub cannot outlive its method again.

Tests: WebViewBridge.framing (a router query goes to nativeBoundaryIngress
as a routerQuery op; a non-bytes native answer is refused),
bridgeDecoding.integration (the boundary's answers, entered through
routerQueryBin). Gate control, failing naming the offender: a test stub arm
answering getSigningPublicKeyBin. Verified: tsc, lint, the boundary and E2E
suites this touches, and the bridge-RPC-name gate; the full suite, build
and remaining gates ran on the tip, with the identity change that follows.

* fix(identity): an identity is answered or refused with its reason — missing, runtime not ready, or not read — never a null

`getIdentity` answered `null` after its cold-start window for three
different reasons: the native session reporting no identity, the session
not ready in time, and headers that could not be read — and it waited the
whole window (~5.75 s) on a device that has no identity at all. `isReady`
reduced a device-id read to a boolean, `false` for an empty answer and for a
failed one alike, and `useTransactions` read that as "no identity yet". The
wallet store treated the null as "not initialized, no error"; the wallet
screen threw "Identity not initialized" for every case; diagnostics printed
"getIdentity answered null".

`getIdentity` now answers the identity or throws `IdentityUnavailableError`
with its state: `missing` — Rust's word, the native session's
`identity_status`, answered at once with nothing to wait for;
`runtime_not_ready` — the session not ready within the window, carrying the
last read's failure; or `read_failed` — a session that reports ready whose
headers still could not be read. The store treats `missing` as a state
(uninitialized, no error, no reads) and reports the other two as errors;
the screen shows the reason; diagnostics print the state and message;
`useTransactions` asks the native session (`checkIdentityState`) and logs
which of the three it saw. `getDeviceIdentity` and `isReady` are deleted
with the dead mocks of them.

Tests: identity.test (the three answers: not-ready after the window with no
session state; missing at once, without the window and without a headers
read; not-read with the reason on a ready session), walletStore.test (a
missing identity leaves the store uninitialized with no error and no reads;
a runtime not ready in time is an error as reported), useDiagnostics.test
(a missing identity reported as missing), useTransactions.test (no history
read while the session reports NO_IDENTITY or RUNTIME_NOT_READY). Mutation
controls, each red on its named test: the missing fast-exit removed; a
failed window answering null again; the store treating a missing identity
as an error. Verified: tsc, lint, jest 839/839, npm run build, and the
purity, flow, scan, bridge, codegen, proto and bridge-RPC-name gates.

* chore(frontend): production exports nothing calls go, with the WebView's startup-boundary path Kotlin crosses on its own

A survey of production exports nothing in production references found five
whole modules and fourteen functions dead: `dsm/crypto.ts`, `dsm/nfc.ts`,
`vectors/`, `services/policy/policyScanService.ts`, `utils/binary.ts`;
`getNativeHostCapabilities`, `stopNativeQrScan`, `requestHostPermissions`,
`authorizeBiometricHost`, `readNfcTagPayloadHost`, `invokeRouterEnvelope`,
`queryRouterEnvelope`, `loadPersistedBleMappings` (a documented no-op),
`subscribeBleEvents`, `useBle`, `useUXTerms` ("terms" that answered the key
they were asked for), `useBridgeSessionStore`. The WebView's
startup-boundary path — `startupBoundary` / `startupBoundaryOk`, the
`startup` wrapper index.html installed, the `startup` member of
`AndroidBridgeV3`, a test-setup stub arm and Kotlin's `nativeBoundaryStartup`
arm — had no caller: Kotlin crosses the startup boundary itself at app start
(`BridgeIdentityHandler.dispatchStartupOrThrow`), and the bridge-RPC-name
gate had counted the wrapper's own `callBridgeMethod('nativeBoundaryStartup')`
as "sent".

All deleted. `AndroidBridgeV3` and index.html's object are six members;
`callBoundaryMethod` crosses the ingress boundary only; the Kotlin fuzz
lists lose the name. Exports referenced only by tests are production code
kept alive by tests and are recorded Open in CONFORMANCE_GAPS §6.29, to be
swept export by export; Bitcoin and recovery exports are not touched.

Tests: none added — deletions. Gate controls: the `nativeBoundaryStartup`
stub arm in setupTests.ts, refused by name while the Kotlin arm was already
gone; a `startup` member left on `AndroidBridgeV3` after index.html dropped
it, refused as typed but not installed. Verified: tsc, lint, jest 836/836,
npm run build, the Kotlin main, androidTest and unit-test compiles, and the
purity, flow, scan, bridge, codegen, proto and bridge-RPC-name gates.

* chore(frontend): production exports kept alive only by tests go, harnesses move to the tests, and the committed signal fires once

Exports referenced by nothing in production and by tests only: a second
balances decoder (`decodeBalancesListResponseStrict`) beside the one
`getAllBalances` uses, `encodeEnvelope`, an event encoder production never
encodes with (`encodeBilateralEventNotification`), `parseBinary32`/`64`,
`clearBleIdentityCache`, `pruneBleIdentityMappings`, a second history reader
(`getTransactions`), `getBluetoothStatus`, `mapPoliciesToDisplayEntries` and
its module, six wallet-store selector hooks no screen calls,
`decodeBase32Crockford32`, `FX_ANIMS`, and the native session store's
`…ForTest` setters. The tests of them tested nothing real; one
"reproduction" test mimicked the balance mapper with its own fallback and
`console.log`. The accept path also dispatched the committed signal twice:
as a window event the adapter re-emitted on the bus, and on the bus again.

Dead exports deleted with the tests of them. The event encoder moves to
`tests/helpers/bilateralEventFixture.ts`, a harness the tests own. The
session store is driven in its tests by the production input —
`session.state` on the bus — and made fresh per test by module isolation;
the BLE cache tests likewise. The accept path emits the committed signal
once, through `emitBilateralCommitted`, and the window hop for it is
deleted with the adapter's listener.

Tests: bilateralAcceptEvent (the committed signal exactly once per accept),
utils/identity (the session published on the bus, a fresh store per test),
resolution (a fresh module per test). Mutation control, red on its named
test: the committed signal emitted twice. Verified: tsc, lint, jest 788/788
across 117 suites (48 tests of dead code gone with it), npm run build, and
the purity, flow, scan, bridge, codegen, proto and bridge-RPC-name gates.

* fix(wallet): the wallet screen reads the one wallet store; its private copy of balances and history goes

The wallet screen held its own copies of balances (a private `Balance`
shape mapped from `TokenBalanceView`) and history beside the provider's
store, and reloaded them — with the identity and the contacts — on every
`wallet.refresh`, so one wallet change was two reads each of `balance.list`
and `wallet.history` and one of `contacts.list` and the headers.

The screen now reads balances and history from the wallet store, the one
copy, reloaded once per change in the provider; the tabs take
`TokenBalanceView` and the private `Balance` type is deleted. The screen
owns the identity (read once, or the reason it was not) and the send tab's
contacts with their send-readiness, re-read when the contacts store reports
a change and on a manual refresh; a manual refresh reloads the store and the
contacts. The overview says "Loading balances…" while the store has not
answered, rather than "No balances yet". What the stores report failed is
the screen's warning, in their words.

Tests: EnhancedWalletScreen.events — every test mounts the screen inside the
wallet provider, as the app does, and the store is reset per test; `one
inbox sync with new items reloads the wallet data once` now counts the
store's one reload with the screen adding none. Mutation control, red on
that test: a `wallet.refresh` reload re-added to the screen hook beside the
store's. Verified: tsc, lint, jest 788/788, npm run build, and the purity,
flow, scan, bridge, codegen, proto and bridge-RPC-name gates.

* docs(conformance): the send tab's contacts, a second copy beside the contacts store, recorded Open

* fix(contacts): one contact shape from the one mapper, in the contacts store; the wallet screen's copy of the contacts goes

Two mappers read Rust's contact list into two shapes: the contacts store's
`Contact` (`id`, `publicKey`, `isVerified`, the raw BLE address) for the
contacts screen, and `mapContactList`'s `DomainContact` (the signing key,
`genesisVerifiedOnline`, the normalised BLE address or the one the native
side resolved this session, and the send-readiness Rust reports) for the
send tab — so the wallet screen kept its own copy of the contacts, re-read
from Rust beside the store's.

The contacts store maps with `mapContactList` into `DomainContact`, the one
shape every screen reads; `Contact` and `mapContacts` are deleted; the
contacts screen reads `deviceId`, `signingPublicKey` and
`genesisVerifiedOnline`; the wallet screen reads the store's contacts and
re-reads nothing of its own. A manual refresh reloads both stores.

Tests: contactsStore (the DomainContact shape in Base32 Crockford from the
one mapper; genesisVerifiedOnline as Rust reports it), EnhancedWalletScreen
(the refresh button re-reads balances, history and contacts; the screen's
contacts come from the store the harness loads), the contacts screen tests
on the renamed fields. Mutation control, red on its named test: the manual
refresh no longer reloading the contacts store. Verified: tsc, lint, jest
789/789, npm run build, and the purity, flow, scan, bridge, codegen, proto
and bridge-RPC-name gates.

* fix(events): the native lifecycle topics reach the bus directly; the DOM hops nothing dispatched go, and the identity wake-up finally hears its event

The adapter re-emitted seven DOM events on the bus that nothing ever
dispatched — `dsm-history-updated`, `dsm-balances-updated`,
`dsm-wallet-send-committed`, `dsm-contact-added`, `DSM_PORT_TX`,
`DSM_PORT_RX`, `DSM_UI_TICK` — and `useWalletSync`, the contacts provider
and the loading spinner subscribed to the bus events they would have
produced: reloads and an activity indicator that could never fire. The
native lifecycle topics reached the bus through DOM hops: `dsm-identity-ready`
was dispatched on `document`, re-emitted by the adapter, and listened for by
`getIdentity`'s early wake-up on `window`, where it never arrived, so the
cold-start wait always ran its full delays; `dsm-wallet-refresh` and
`dsm-env-config-error` likewise went DOM → adapter → bus. `session.state`
and `bilateral.event` were also fanned out as DOM events with no listener.

The seven hops, their bus event types, `useWalletSync` (its one live
subscription, `identity.ready`, is now the wallet provider's own), the
contacts provider's `contact.added` subscription and the spinner's activity
effect are deleted. The event bridge emits `identity.ready`, `wallet.refresh`
(`native`) and `env.config.error` on the bus directly; the adapter keeps
only `visibilitychange`; `getIdentity` wakes on the bus event, which now
reaches it; `DSM_WALLET_REFRESH_EVENT` and the two listener-less fan-outs
are deleted. `dsm-biometric-result`, `bluetooth-permissions` and
`ble-dev-automation` stay, recorded Open: whether device automation reads
them from outside the bundle is a device question.

Tests: E2E.uiCoordination (the identity-ready, wallet-refresh and
env-config-error topics each reach the bus with what they carry; the
adapter-hop tests of events nothing dispatched are gone), identity.test (the
wait wakes early on identity.ready). Mutation controls, each red on its
named test: the identity-ready topic no longer emitted on the bus; the
wake-up listener removed. Verified: tsc, lint, jest 777/777 across 116
suites, npm run build, and the purity, flow, scan, bridge, codegen, proto
and bridge-RPC-name gates.

* fix(events): a subscription names an event the bus carries, and the ones that did not — with the BLE gate and provider they drove — go

`useBridgeEvent` took any string, so subscriptions to events nothing can
emit compiled: two toasts (`ble.permission.recovery.needed`,
`ble.features.disabled`) and a `ble.features.enabled` handler — the last
not even a bus event — which together drove a `bleFeaturesDisabled` flag
that gated every BLE call and could never be set. Two BLE advertising events
were emitted with no consumer left, and `nfc.writeStarted` had neither.
`BleContext` provided a context nothing consumed since `useBle` went: scan
state and four no-op calls, mounted in `App` for nothing.

`useBridgeEvent`'s name is now `keyof BridgeEventMap`, so a subscription
to a name the bus does not carry does not compile. The dead subscriptions,
the flag and its gate, the two emits, the three event types and
`BleContext` are deleted. `wallet.exitCompleted`, subscribed to in three
places and emitted by nothing, is Bitcoin's exit flow and is left alone.

Tests: none added — a type and deletions. Compile control: a
`useBridgeEvent('nothing.emits', …)` added to a provider fails tsc, naming
the file. Verified: tsc, lint, jest 777/777 across 116 suites, npm run
build, and the purity, flow, scan, bridge, codegen, proto and
bridge-RPC-name gates.
cryptskii added a commit that referenced this pull request Sep 26, 2026
…exist; the name gate reads androidTest

#1012 deleted five bridge arms the frontend no longer sent (getDeviceIdBin,
getGenesisHashBin, getSigningPublicKeyBin, getPersistedGenesisEnvelope,
getWalletHistoryStrict). AndroidLayerProofTest still called them, so nine of
its tests got the unknown-method answer and the managed-device job has been
red on main since that merge. No gate read androidTest.

The proof now reads what the frontend reads: identity decoded from the
transport headers, history through the wallet.history route, framing and
concurrency over getTransportHeadersV3Bin; t50 also requires every thread to
read the same device id. The tests of the deleted arms are removed.
claimFaucet had been returning before it claimed; the emulator tests that
called it read without it, and the real-hardware test sends faucet.claim as
the frontend does and requires it to land.

ci/bridge_rpc_names.py now requires every bridge name the instrumented suite
sends to be one Kotlin handles, and the unknown-method probe's name to be one
it does not.
cryptskii added a commit that referenced this pull request Sep 26, 2026
…frontend renders (#1021)

* fix(ble): the radio is native's; the frontend makes no radio decision

Advertising follows the device's identity, and native owns it. The BLE
service advertises whenever the device has an identity, derived again on
its own worker thread when the service starts or binds, when the activity
resumes, once genesis or init has produced the identity, when a Bluetooth
permission is granted and when the adapter turns on. The GATT server reads
the identity characteristic from Rust when a peer reads it.

The frontend sends no radio request. offlineSend is one wallet.sendOffline
call; it used to set the advertised identity, start advertising and
scanning, and sleep 1.5 s first, swallowing every failure, and re-advertise
when the send finished. The contacts provider started advertising on
identity readiness and on each learned address, and the wallet screen
started and stopped it on mount, visibility and unmount, so a device on any
other screen could not be reached.

Deleted: the four BLE host controls (NativeHostRequestKind 4-7 reserved),
the BLUETOOTH_PERMISSIONS host event (2 reserved), BridgeRpcRequest
ble_identity (10 reserved) with BleIdentityPayload and the
setBleIdentityForAdvertising RPC, stopBlePairingAdvertise, the caller-less
BleCoordinator.ensureBleReady, the GATT host's pushed identity value, and
PairingTestActivity, a start/stop advertising panel in the production
manifest. The pairing loop stops only its scan.

Tests: the offline send, the wallet screen's lifecycle and the contacts
provider make no radio request; six mutation controls, each red on its
named test. The native lifecycle is compile-checked; a device run shows it.

* fix(ble): the relationship-status read nothing called is gone end to end

The GATT service carried a relationship-status characteristic answering any
connected peer with this device's send status for the contact at that
address, and a client read of it ran from the frontend's
readPeerRelationshipStatus through a bridge RPC and a blocking coordinator
call. Nothing called the frontend function, so the server served a value no
client read.

Deleted: the characteristic and its UUID, the client read with its event and
pending-read slot, the bridge RPC and its Kotlin arm, the JNI export and
BleRelationshipStatusCharValue. Also BleCoordinator.readPeerIdentity, which
answered true whatever happened beside a "for now" comment and had no
caller, and setSessionMode with BleSessionMode: nothing set the mode, so the
scanner's was always idle and read only by a log line.

A relationship's send status is where Rust already reports it, on the
contact list. Gate control: the bridge-name gate refuses the Kotlin arm left
behind, naming it.

* fix(ble): an offline send carries the user's intent; the SDK decides where it goes

The frontend chose where an offline send went. The send form resolved the
recipient's BLE address (the contact's, else a cache of its own, else a
bridge round trip to a native map) and refused a contact it found none for
before Rust was asked. offlineSend sent the address inside a
BilateralPrepareRequest, the device-to-device prepare, whose intent fields
(11-14) existed only for this route and whose ble_address (6) no receiver
read; the route also took a caller-authored operation in place of authoring
one. The contacts mapper filled a missing address from the frontend's cache
and dropped one that was not MAC-shaped. The prepare builder looked an
address up only to fill field 6, and when the contact had none wrote the
session's address into the contact, the mark that ends the pairing loop,
outside the pairing confirm.

wallet.sendOffline takes OfflineTransferRequest: counterparty, token, amount
and memo, as the user gave them. The SDK resolves the address
(bluetooth::peer_address): the one the contact holds, else the one its
identity, checked against the contact's genesis, was seen at this session;
a phone it has not met is refused, saying so. It authors the operation from
the intent. BilateralPrepareRequest 6 and 11-14 are reserved. The session
map moved out of jni::state into a host-compiled, tested module.

Deleted: the frontend resolver module with its cache and normalizer, the
resolveBleAddressForDeviceId RPC with its Kotlin arm, wrapper and JNI
export, recordPeerIdentity (unverified, and nothing called it) and
resolve_ble_address (nothing called it).

Tests: the resolver's order, the route refusing then passing on the
contact's address, the request's exact bytes, the form naming no address,
the mapper carrying Rust's address as-is. Six mutation controls, each red
on its named test.

* fix(ble): pairing follows the session; the contacts screen no longer runs it

The contacts screen ran BLE pairing: it started the loop when it counted
more unpaired contacts than before and stopped it when it unmounted, so
pairing ran only while that screen was open.

Pairing now follows the session, as the lock does. Rust starts the loop when
the facts Kotlin already reports say the app is in the foreground with
Bluetooth on and permitted and there is an identity to pair as
(SessionManager::pairing_may_run), and stops it when they say otherwise; the
loop still ends by itself once no contact is unpaired. Adding a contact
wakes or starts the loop when the session lets it run. Kotlin creates the
BLE coordinator the loop drives when the identity's BLE service starts,
before those facts go out, as the old start arm did. The scanning hint no
longer asks to keep both devices on this screen.

Deleted: the screen's start and stop, the startPairingAll and stopPairingAll
RPCs with their Kotlin arms, wrappers and JNI exports, and the
hasUnpairedContacts chain down to its query, which answered false when the
database could not be read and which nothing called.

Tests: the session's pairing decision; the contacts screen asks for nothing
but reads. Three mutation controls, each red on its named test. The Android
start and stop are compile-checked; a device run shows them.

* fix(send): a BLE transport error fails no offline send

A BLE transaction error frame ended the send in flight as failed. Kotlin
raises that frame for any failed connection to any address (a failed
identity read, a failed GATT connect, a failed connection-state call), so a
pairing attempt with another phone could fail a send, and a lost link, which
is liveness, was reported as a failed transfer while the step stayed open.

The listener is deleted: the send ends on Rust's word, its events or its
pending list, and a screen that stops waiting reports the step open.

Test: a transport error frame mid-send leaves the send waiting, and Rust's
completion finishes it. Mutation control: the listener restored turns it
red.

* fix(pairing): paired means the address is stored; the list states where pairing stands

Both pairing completions marked the session Complete, and told the screen
the contact was paired, when storing its address failed. The loop never
revisits a Complete session, so such a contact stayed unpaired until the
process restarted, among them a phone paired before it was added as a
contact, whose store fails for want of the contact. The loop reported only
"scanning", so the contacts screen inferred progress from raw radio events
about any phone: "Peer Found" on any DSM advertisement, and "Paired!" when
a phone's identity was read, before pairing had completed.

A session now completes only once the address is stored; a failed store
fails the session and the loop retries it. The loop reports each
transition, and contacts.list states each contact's phase from the loop's
sessions (ContactAddResponse.pairing, ContactPairingPhase: paired, idle,
searching, connected, retrying; the frontend refuses a phase the wire does
not name). The screen's line renders those phases and no longer listens to
raw radio events; a pairing event re-reads the list. A dropped link no
longer sets the session's state twice.

The orchestrator's database tests set their own storage directory; they
passed only when an earlier test had set one.

Tests: both completions refuse an unstored address, the phase mapping, the
list route over two devices (idle, searching, paired), the strict phase in
the frontend, the line. Seven mutation controls, each red on its named test.

* docs(ble): say device, never phone; the offline help no longer asks for the wallet screen

DSM runs on devices, and offline goes appliance to appliance. The BLE
work said "phone" in comments, test names, the offline send's refusal
("the devices have not met over BLE" now), the pairing hint and the
conformance rows.

The send screen's help told both people to stay on the wallet screen. That
screen no longer runs the radio: the device advertises while it has an
identity, and the incoming-transfer prompt is mounted for the whole app.
The help now says the recipient accepts in the app.

* docs(conformance): a prepare for another relationship is not meaningless bytes to its receiver

Recorded open in §6.29. The receiver never checks that a prepare is for it.
When the sender is also its contact, the tip mismatch stores the sender's
claimed tip as a live-peer claim, which blocks this device's sends to the
sender, online as well as offline, and nothing outside recovery clears it.
A throwaway probe reproduced it. The prepare's target field is not under the
sender's signature; the operation in the signed commitment names the
recipient.

* docs(ble): the offline side is the appliance

Offline goes appliance to appliance: pairing, advertising and the radio
are the hardware side, and the text about them says "appliance", including
the offline refusal ("the appliances have not met over BLE"), the send
help, the pairing hint and the conformance rows. Online is an account that
can be on any device, so nothing here describes it in device terms.
Identifiers such as device_id keep their names.

* test(android): the instrumented proof calls only bridge methods that exist; the name gate reads androidTest

#1012 deleted five bridge arms the frontend no longer sent (getDeviceIdBin,
getGenesisHashBin, getSigningPublicKeyBin, getPersistedGenesisEnvelope,
getWalletHistoryStrict). AndroidLayerProofTest still called them, so nine of
its tests got the unknown-method answer and the managed-device job has been
red on main since that merge. No gate read androidTest.

The proof now reads what the frontend reads: identity decoded from the
transport headers, history through the wallet.history route, framing and
concurrency over getTransportHeadersV3Bin; t50 also requires every thread to
read the same device id. The tests of the deleted arms are removed.
claimFaucet had been returning before it claimed; the emulator tests that
called it read without it, and the real-hardware test sends faucet.claim as
the frontend does and requires it to land.

ci/bridge_rpc_names.py now requires every bridge name the instrumented suite
sends to be one Kotlin handles, and the unknown-method probe's name to be one
it does not.

* test(android): the identity BLE reads is the headers' identity; a refused route fails with Rust's reason; the name gate reads every send

Review of the previous commit found four defects in it.

- The deleted arms were the suite's only route to the JNI identity exports
  Unified.getDeviceIdBin/getGenesisHashBin, which BLE still reads (the GATT
  identity characteristic, the advertising gate). t22 now calls them
  directly and requires them to equal the device id and genesis hash in the
  transport headers the frontend reads. No test had compared the two.
- claimFaucet asserted OK_BYTES with no message, so a refusal lost Rust's
  reason, and t40's success check could never fail (the handler answers a
  FaucetClaimResponse only on success). A refused route now fails with
  Rust's reason, and t40 requires a release.
- The gate missed t31, which encoded its method field by hand; t31 now goes
  through the one encoder, and the gate also reads a hand-encoded method
  field. The probe rule missed a typed declaration and passed with no
  probe; the probe must now be readable, sent by the suite, and unhandled.
- The §6.29 row said t42 compared the headers with getDeviceIdBin. It never
  compared anything; the row now says so.

Run on the emulator, t40 is refused because ensureGenesis installs the
loopback test config ("member instrumented-node-1 has a register_incarnation
that is not Base32-Crockford"). It cannot pass on any device until the suite
installs a config naming the pinned set; recorded Open.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant